fix(provider): preserve assistant message content when reasoning blocks present#21370
fix(provider): preserve assistant message content when reasoning blocks present#21370edevil wants to merge 2 commits intoanomalyco:devfrom
Conversation
|
The following comment was made by an LLM, it may be inaccurate: Potential Duplicate Found:
This PR appears to be directly related or a potential duplicate. It addresses the same issue (#16748) with the same root cause (empty-text filtering in |
|
It seems this error message predates adaptive thinking so it must be something else. |
|
I managed to confirm with a past session that this does indeed fix the issue in question. |
Port anomalyco/sst upstream dev's proven sync architecture to permanently
fix the flaky "blank subagent view" bug where ctrl+x down into a running
subagent showed an empty content area until completion.
sync.tsx:
- Remove `force` parameter from session.sync()
- Always mark fullSyncedSessions after fetch (drop time.completed gate)
- Remove redundant session.created handler (session.updated upserts)
- Match upstream/dev byte-for-byte in sync-relevant sections
routes/session/index.tsx:
- Task component: createEffect -> onMount with empty-messages guard
- Route-level sync: drop force=true argument
Preserved defensively (ahead of upstream):
- worker.ts + server/routes/event.ts still use GlobalBus.on("event", ...)
as defense-in-depth on top of InstanceState shared PubSub
- worker.ts WASM stdout/stderr capture to suppress Aborted() TUI corruption
- worker.ts per-directory filter in GlobalBus.on handler
provider.ts:
- Raise FLOOR_CEILINGS.critical from 4 to 10 for non-Anthropic providers
- Expand isAnthropicParent to also match parentModelID containing "claude"
(covers claude-via-proxy cases)
transform.ts:
- Preserve assistant messages with reasoning blocks verbatim to avoid
Anthropic 400 "thinking blocks cannot be modified" (ref upstream PR anomalyco#21370)
- Enable 4-BP cache strategy for GitHub Copilot Claude models
llm.ts:
- Use 5m TTL for short-lived subagent BP2 cache breakpoint; 1h for long-lived
processor.ts:
- Remove duplicate SessionSummary.summarize() call that triggered full-history
DB hydration on every LLM step in multi-step tool loops (ref upstream anomalyco#21762)
Tests:
- task-escalation.test.ts: update FLOOR_CEILINGS critical assertions to
match new ceiling (critical > opus, toBe(10)); expand coverage
- cache-max-efficiency-e2e.test.ts: new E2E test validating Anthropic prompt
cache hit rate across multi-turn conversations
Skill:
- Add opencode-prompt-cache-maximization skill (CLAUDE.md)
Submodule:
- Bump cmd/opencode-search-engine to include idle CPU perf improvements
Root cause: our prior fullSyncedSessions + time.completed guard raced
with Task.createEffect's fire-and-forget sync(id, force=false) -- first
fetch at T=0 returned 0 messages, session was never marked synced,
and no re-sync trigger existed, leaving the view blank until the
subagent finished.
|
I hit another wrinkle while tracing the same bug locally. Even after I stopped I opened #21860 with a repro for that path too, in case it's useful. |
b678059 to
d2e78ad
Compare
|
Verified the fix works and is at the correct layer. Sharing research findings and a small scoping refinement for your consideration. Filter chain investigation: The empty text parts originate from Anthropic itself — adaptive thinking with
And dropping the part entirely shifts reasoning-block positions → Scoping refinement suggestion: the current condition const hasSignedReasoning = msg.parts.some(
(p) => p.type === "reasoning" && p.metadata?.anthropic?.signature != null,
)
const text = part.text === "" && hasSignedReasoning ? " " : part.textThis self-identifies as Anthropic via signature presence (also covers Bedrock/Vertex Anthropic), avoids threading provider info into Tests that would pair nicely:
Out of scope for this PR: the redundant empty-text branch in |
d2e78ad to
517afe4
Compare
PR anomalyco#21370 only checks metadata.anthropic.signature, but Anthropic Claude hosted on AWS Bedrock (amazon-bedrock/anthropic.*) stores signatures under metadata.bedrock.signature, and on GCP Vertex AI under metadata.vertex.signature. Without this extension, the hasSignedReasoning gate never fires for Bedrock/Vertex users, and the empty-text-between-thinking-blocks bug still triggers 'thinking or redacted_thinking blocks in the latest assistant message cannot be modified' on compaction.
|
Ran into a gap while verifying this fix against AWS Bedrock Claude Opus 4.7 — the
Result: on Bedrock/Vertex the gate never fires, the empty-text substitution is skipped, and the Confirmed by inspecting a real session's reasoning part metadata in SQLite: { "type": "reasoning", "text": "...", "metadata": { "bedrock": { "signature": "Es4EClk..." } } }Opened a small follow-up against @edevil's branch with a 3-namespace check + Bedrock/Vertex test coverage: edevil#1 The rest of the PR works great — verified on a production Bedrock session that was reliably reproducing the issue on manual compact; no longer triggers after applying both fixes. |
…ng blocks
Anthropic adaptive thinking (Opus 4.6+) emits empty text parts between
thinking blocks. Dropping them invalidates thinking block signatures
('thinking blocks cannot be modified'), but sending them as '' fails
validation ('text content blocks must be non-empty'). Substitute a
single space so the part survives all filters and Anthropic accepts it.
Gated on reasoning having an Anthropic signature (metadata.anthropic.
signature != null) so other providers' reasoning — which don't
position-validate — continue to have empty text filtered normally.
Closes anomalyco#16748
The hasSignedReasoning gate introduced in this PR only matches Anthropic's direct-API signature path (metadata.anthropic.signature). When Claude is hosted on AWS Bedrock or GCP Vertex AI, the reasoning part metadata stores the signature under metadata.bedrock.signature / metadata.vertex.signature respectively. The gate never fires for those providers, so empty text parts between signed reasoning blocks are not substituted with a space, and Anthropic/Bedrock/Vertex still reject the compacted message with: messages.N.content.M: 'thinking' or 'redacted_thinking' blocks in the latest assistant message cannot be modified Extend the check to match signatures under any of the three provider namespaces. Adds two tests covering the Bedrock and Vertex paths.
517afe4 to
876c79f
Compare
|
Thanks @omer-koren for catching the Bedrock/Vertex gap and the clean follow-up. Rebased on current edevil#1 can be closed. |
Issue for this PR
Closes #16748
Type of change
What does this PR do?
Anthropic adaptive thinking (Opus 4.6+, Sonnet 4.6) emits empty text parts between thinking blocks:
The empty text part originates from Anthropic itself — adaptive thinking with
"omitted"mode (default on Opus 4.7+) emitscontent_block_start{type:"text"}immediately followed bycontent_block_stopwith no deltas, as a structural separator between thinking blocks.On replay, three separate gates reject an empty-string text part:
normalizeMessages(transform.ts) — dropstext === ""convertToLanguageModelPrompt— dropstext === ""unlessproviderOptions != null"text content blocks must be non-empty"And dropping the part entirely shifts reasoning-block positions, invalidating signatures with:
Once either rejection fires, the session is permanently broken — the corrupted history replays from storage on every retry.
The fix
Substitute a single space for empty text parts in assistant messages that contain signed Anthropic reasoning blocks. The space preserves the structural arrangement (the reasoning block positions) without triggering any of the three rejections. Signatures are on the reasoning block content, not on the text between them, so a non-empty placeholder keeps the arrangement valid.
The gate self-identifies as Anthropic via signature presence (also covers Bedrock/Vertex Anthropic). Other providers' reasoning (OpenAI Responses, Gemini thinking, Copilot
reasoningOpaque) don't position-validate, so their empty text continues to be filtered normally.This is a single 3-line change in
message-v2.tswhere UIMessage parts are built from database parts. By the time the message reachesnormalizeMessages()or the AI SDK's internal filters, the text is already" "(non-empty), so it passes all downstream checks naturally.Historical context
normalizeMessagesempty-text filter was added on Jan 5 (c285304a) before adaptive thinking existed (Feb 13,0d90a22f9), so it was correct at the time33819932e(Apr 10) removedtrimEnd()calls inprocessor.tsas a partial mitigation, but only helps when text is whitespace — the model can still emit truly empty text parts that trigger the bugclaude-opus-4-7; no commits between v1.4.6 and currentorigin/devfix itHow did you verify your code works?
message-v2tests: space substitution with signed reasoning; unsigned reasoning leaves empty text alone; no-reasoning messages leave empty text aloneScreenshots / recordings
N/A — backend logic change, no UI impact.
Checklist